# SimpleScope
#
# See the README for a description of the plugin.
module SimpleScope

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods

    # Takes a model, some arguments and an optional block to create an around filter for adding
    # and removing our shorthand scoping hash.
    def scope_filter(model, *args, &block)
      filter_options, scope_options = extract_scope_options(args.last.is_a?(Hash) ? args.pop : {})
      around_filter SimpleScopeFilter.new(model, *(args << scope_options), &block), filter_options
    end
    
    protected
    def extract_scope_options(options)
      collector = Proc.new { |k,v| [:cache, :class].include?(k) }
      [options.reject(&collector), options.reject { |k,v| !collector.call(k,v) }]
    end

  end
  
  class SimpleScopeFilter

    # Prepare the filter with the model (ActiveRecord class) to scope, the class for the scoping
    # which defaults to the model, and ensure that there is a source for the scoping hash.
    def initialize(model, *args, &block)
      raise TypeError, "No class provided" unless (@model = model).is_a?(Class)

      @method = args.first.is_a?(Symbol) ? args.shift : nil
      @block = block_given? ? block : nil
      raise ArgumentError, "Missing block or method for scoping" unless @block || @method

      @options = args.last.is_a?(Hash) ? args.pop : {}
      @klass = (@options[:class] || @model)
    end

    # Before an action is executed, retrieve the scoping (whether by method or block) and
    # perform some sanitisation on the hash it returns. Push the hash, in a rather bruteforce
    # way, on to the present scopings of the model in question. Tell the development log we are
    # doing all sorts of horrible things.
    def before(controller)
      scoping = @method ? controller.send(@method) : @block.call(controller)
      raise TypeError, "Scoping must be a hash of attributes" unless scoping.is_a?(Hash)
      
      constraints = prepare_constraints(scoping)

      scoped_methods << @model.with_scope(constraints) { current_scoped_methods }
      @model.logger.debug("[%s] %s#before:\n  %s scoping for %s:\n    %s" %
        [self.class.parent, controller.class, @klass, @model, scoped_methods.inspect])
    end

    # After the action has executed, pop the last scoping (the one we created) and tell the
    # development log that we have stopped doing horrible things.
    def after(controller)
      @model.send(:scoped_methods).pop
      @model.logger.debug("[%s] %s#after:\n  %s scoping for %s:\n    %s" %
        [self.class.parent, controller.class, @klass, @model, scoped_methods.inspect])
    end
    
    protected

    # Gets all the scopings of the model, using a send hack
    def scoped_methods
      @model.send(:scoped_methods)
    end
    
    # Gets the current scoping of the model, using a send hack
    def current_scoped_methods
      @model.send(:current_scoped_methods)
    end

    # Gets the column names from the klass (i.e. the table in question)
    def column_names
      @column_names ||= @klass.column_names.map(&:to_sym)
    end
    
    # Gets the conditional based on the value's class, using a send hack
    def attribute_condition(value)
      @klass.send(:attribute_condition, value)
    end

    # Generates the scoping hash for with_scope
    # Uses reject instead of select/partition because they return arrays.
    # Note: Performs no generation if :find or :create are already present
    def prepare_constraints(scoping)
      return scoping if scoping[:find] || scoping[:create]
      attributes = scoping.reject { |k,v| !column_names.include?(k) }
      options = scoping.reject { |k,v| column_names.include?(k) }
      { :find => construct_find(attributes, options), :create => construct_create(attributes) }
    end
    
    # Generates the create values out of all valid attributes for klass.
    def construct_create(attributes)
      attributes.reject { |k,v| !v.is_a?(@klass.columns_hash[k.to_s].klass) }
    end
    
    # Generates the find values, merging conditions with the other options.
    def construct_find(attributes, options)
      conditions = construct_conditions(attributes, options)
      conditions.empty? ? options : options.merge({:conditions => conditions})
    end
    
    # Generates the conditions array, merging :conditions with the autogenerated one if it exists.
    def construct_conditions(attributes, options)
      conditions = attributes_to_conditions(attributes)
      options[:conditions] ? merge_conditions([options[:conditions], conditions]) : conditions
    end
    
    # This is totally unnecessary if you have Rails Edge (1.2 or higher), since hashes are
    # dealt with. It just converts hashes to the array format (the only one supported in 1.1.4).
    def attributes_to_conditions(hash)
      conditions = []
      hash.each do |k,v|
        if @klass.columns_hash[k.to_s]
          conditions << ["%s.%s %s" % [@klass.table_name, k, attribute_condition(v)], v]
        end
      end
      merge_conditions(conditions)
    end
    
    # Takes an array of condition fragments and puts them together using AND
    def merge_conditions(fragments)
      return [] if fragments.empty?
      fragments, statements, values = fragments.dup, [], []
      fragments.each do |fragment|
        statements << fragment and next if fragment.is_a?(String)
        statements << fragment.shift
        fragment.each { |v| values << v }
      end
      values.unshift statements.join(' AND ')
    end

  end

end